from random import Random

from utils.fsm import FSM
from utils.logger import Logger
from utils.clock import Clock
from utils.gauge import Gauge
from utils.channel import Channel
from utils.namespace import Namespace
from utils.misc import INF, check_type
from opt.infeasible import Infeasible

from .sense import OptimizationSense
from .solutions import SolutionList
from .interrupts import InterruptManager
from .status import StatusPrinter
from .params import Param, ParamSet
from .termination import TerminationStatus, TERMINATION
from .statemachine import STATE, ACTION, TRANSITIONS
from .signals import SIGNALS
from .plugins.plugin import Plugin
from .plugins.limits import Limit, LimitManager
from .plugins.manager import PluginManager
from .plugins.displaystatus import DisplayStatus
from .plugins.checkoptimality import CheckOptimality


class Solver(FSM):
    """
    Base class for solvers. Defines a basic set of attributes, and a finite state machine (states,
    actions, transitions) that can be used by all solvers.
    """
    STATE = STATE              # namespace containing solver states
    ACTION = ACTION            # namespace containing solver actions (i.e. transition symbols)
    SIGNALS = SIGNALS          # namespace containing solver signals
    TERMINATION = TERMINATION  # namespace containing solver termination statuses

    Plugin = Plugin            # provide indirect access to solver plugin class
    Limit = Limit              # provide indirect access to solver limit class
    Param = Param              # provide indirect access to solver parameter class

    ParamSet = ParamSet        # parameter set class used by the solver
    instance_loader = None     # callable used for loading instances
    default_plugins = [DisplayStatus, CheckOptimality]  # plugins installed by default

    def __init__(self, **params):
        FSM.__init__(self)
        self.channel = Channel(name=type(self).__name__,   # asynchronous communication channel
                               type_validation=True,       # used by solver plugins
                               callback_mode=Channel.CALLBACK_NO_ARGS,
                               register_types=self.SIGNALS.ALL)
        self._instance = None                              # instance being solved
        self._sense = None                                 # optimization sense (min or max)
        self._incumbent = None                             # value of (feasible) incumbent
        self._bound = None                                 # value of best bound
        self._termination = None                           # search termination status
        self.solutions = SolutionList(self)                # list of solutions found
        self.interrupts = InterruptManager(self)           # interrupt messages and actions
        self.limits = LimitManager(self)                   # solver run limits
        self.plugins = PluginManager(self)                 # solver plugins
        self.status = StatusPrinter(self)                  # display status messages
        self.rng = Random()                                # standard RNG
        self.cpu = Clock()                                 # cpu tracker
        self.iters = Gauge()                               # iteration counter
        self.log = Logger()                                # log manager
        self.meta = Namespace()                            # metadata namespace
        # create paramset and install default plugins only after all other attributes are set up
        self.params = type(self).ParamSet(self, **params)  # configuration parameters
        self.plugins.extend(self.default_plugins)          # install default plugins
        self.enter(self.STATE.UNINITIALIZED)               # enter uninitialized state

    # --------------------------------------------------------------------------
    # Basic solver dynamic properties
    @property
    def instance(self):
        """The instance to be solved.  When setting a new instance, if the argument is a string
        and a loader function is available, the loader is used to load the instance based on its
        "name" (e.g. a filename).  If either the instance is not a string, or no loader function
        is available, the instance is set unchanged.  The instance can only be set during
        initialization."""
        return self._instance

    @instance.setter
    def instance(self, instance):
        if self.state != self.STATE.INITIALIZED:
            raise Exception("instance can only be set during initialization")
        if isinstance(instance, str) and self.instance_loader is not None:
            self.meta.instance = instance
            self.log.info("Loading instance {!r}...", instance)
            self.channel.emit(self.SIGNALS.INSTANCE_LOADING)
            self._instance = self.instance_loader(instance)
            self.log.info("Instance {!r} loaded.", instance)
            self.channel.emit(self.SIGNALS.INSTANCE_LOADED)
        else:
            self._instance = instance
        self.channel.emit(self.SIGNALS.INSTANCE_SET)

    @property
    def sense(self):
        """The solver's optimization sense defines how objective values compare to each other,
        i.e. which values are better than others.  The classical senses are minimization and
        maximization.  This property's setter accepts either an OptimizationSense object or a
        synonym registered in the OptimizationSense class."""
        return self._sense

    @sense.setter
    def sense(self, sense):
        if self.state not in (None, self.STATE.UNINITIALIZED, self.STATE.INITIALIZED):
            raise Exception("objective sense can only be set while "
                            "uninitialized or during initialization")
        self._sense = OptimizationSense.get(sense)

    @property
    def incumbent(self):
        """The value of the best (feasible) solution found so far.  This is initialized to the
        worst possible value defined by the solver's sense, and can only be assigned to in an
        improving manner (e.g. the incumbent can only decrease in a minimization problem),
        otherwise ValueError is raised."""
        return self._incumbent

    @incumbent.setter
    def incumbent(self, new_value):
        if isinstance(new_value, Infeasible):
            raise TypeError("incumbent value should not be infeasible")
        old_gap = self.gap
        old_value = self._incumbent
        if not self._sense.is_better(new_value, old_value):
            raise ValueError("new incumbent must be better than previous incumbent")
        if self._sense.is_better(new_value, self._bound):
            raise ValueError("incumbent cannot better than best bound")
        self._incumbent = new_value
        self.channel.emit(self.SIGNALS.INCUMBENT_CHANGED)
        if old_gap != self.gap:
            self.channel.emit(self.SIGNALS.GAP_CHANGED)

    @property
    def bound(self):
        """The value of the best possible bound on the optimal objective value.  This is
        initialized to the best possible value defined by the solver's sense, and can only be
        assigned to in a tightening manner (e.g. the bound can only be increased in a minimization
        problem), otherwise ValueError is raised."""
        return self._bound

    @bound.setter
    def bound(self, new_value):
        if isinstance(new_value, Infeasible):
            raise TypeError("bound value should not be infeasible")
        old_value = self._bound
        if new_value == old_value:
            self.log.warning("attempting to set solver bound to the same value")
            return
        if not self._sense.is_better(old_value, new_value):
            raise ValueError("new bound must be tighter than previous bound")
        if self._sense.is_better(self._incumbent, new_value):
            raise ValueError("bound cannot be worse than incumbent")
        old_gap = self.gap
        self._bound = new_value
        self.channel.emit(self.SIGNALS.BOUND_CHANGED)
        if old_gap != self.gap:
            self.channel.emit(self.SIGNALS.GAP_CHANGED)

    @property
    def gap(self):
        """The distance between the incumbent and the bound, divided by the absolute value of the
        incumbent."""
        incumbent = self._incumbent
        bound = self._bound
        return (0.0 if bound == incumbent else
                INF if incumbent == 0.0 else
                abs(incumbent - bound) / abs(incumbent))

    @property
    def termination(self):
        """The termination status of the search, specifying an interrupt message and signal.  When
        this property is assigned to, the solver is interrupted with the termination message and
        the 'finish' action, and the termination's signal (if available) is emitted."""
        return self._termination

    @termination.setter
    def termination(self, status):
        if self._termination is not None:
            raise Exception("duplicate attempt to set solver termination status")
        if self.state not in (self.STATE.BOOTSTRAPPED, self.STATE.RUNNING, self.STATE.FINISHED):
            raise Exception("cannot set termination status in state {!r}".format(self.state))
        if isinstance(status, str):
            status = self.TERMINATION[status.upper()]
        check_type(status, TerminationStatus)
        self._termination = status
        self.interrupts.add(status.message, self.ACTION.FINISH)
        if status.signal is not None:
            self.channel.emit(status.signal)

    # --------------------------------------------------------------------------
    # Solver states
    @FSM.State
    def uninitialized(self):
        """Initial solver state. It is used when the solver is not conducting a search."""
        self.channel.emit(self.SIGNALS.SOLVER_UNINITIALIZING)
        self._incumbent = None
        self._bound = None
        self._termination = None
        self.solutions.clear()
        self.interrupts.clear()
        self.limits.clear()
        self.status.clear()
        self.cpu.clear()
        self.iters.clear()
        self.meta.clear()
        self.meta.solver_class = str(type(self))
        self._reset()
        self.channel.emit(self.SIGNALS.SOLVER_UNINITIALIZED)

    @FSM.State
    def initialized(self, instance=None, *limits, **params):
        """After entering this state, the solver has received an instance and is ready to start a
        search. The search always starts by transitioning into bootstrap and only then starts the
        main loop in the 'running' state."""
        def reset(error):
            self.log.error(error)
            self.log.info("Resetting...")
            return self.input(self.ACTION.RESET)

        self.channel.emit(self.SIGNALS.SOLVER_INITIALIZING)
        if not hasattr(self, "objective"):
            return reset("Objective function undefined. Please define an objective function.")
        if self._sense is None:
            return reset("Objective sense undefined. Please define an objective sense.")
        self._incumbent = self._sense.worst_value
        self._bound = self._sense.best_value
        self.limits.set(limits)
        params.setdefault("seed", None)
        self.params.set(params)
        self.meta.initial_params = self.params.get()
        if instance is not None:
            self.instance = instance
        if self.instance is None:
            return reset("Instance not found. Please provide an instance to init().")
        self._init()
        self.channel.emit(self.SIGNALS.SOLVER_INITIALIZED)

    @FSM.State
    def bootstrapped(self, *limits):
        """This state is used to set any initial state for the search before entering the main
        loop in the 'running' state.  To do so, subclasses should merely implement _bootstrap(),
        which is by default a no-op method.  Solvers that do not require bootstrap behavior may
        use the default _bootstrap() method."""
        if len(limits) > 0:
            self.limits.set(limits)
        with self.cpu.tracking():
            self.channel.emit(self.SIGNALS.SOLVER_BOOTSTRAPPING)
            self._bootstrap()
            self.channel.emit(self.SIGNALS.SOLVER_BOOTSTRAPPED)
        if len(self.interrupts) > 0:
            return self.interrupts.apply()
        return self.input(self.ACTION.RUN)

    @FSM.State
    def running(self, *limits):
        """The solver's main loop. The solver calls _iterate() until an interrupt is received.
        Subclasses are **required** to implement the _iterate() method."""
        if len(limits) > 0:
            self.limits.set(limits)
        self.interrupts.clear()
        with self.cpu.tracking():
            self.channel.emit(self.SIGNALS.SOLVER_RUNNING)
            self.limits.check()
            try:
                while len(self.interrupts) == 0:
                    self.channel.emit(self.SIGNALS.ITERATION_STARTING)
                    self._iterate()
                    self.iters.incr()
                    self.channel.emit(self.SIGNALS.ITERATION_FINISHED)
            except KeyboardInterrupt:
                self.channel.emit(self.SIGNALS.KEYBOARD_INTERRUPT)
                self.interrupts.add("keyboard interrupt", self.ACTION.PAUSE)
        return self.interrupts.apply()

    @FSM.State
    def paused(self):
        """This state is used when the solver is interrupted but the search space is still not
        completely explored."""
        self.channel.emit(self.SIGNALS.SOLVER_PAUSING)
        self.channel.emit(self.SIGNALS.SOLVER_PAUSED)
        return self.solutions.best()

    @FSM.State
    def finished(self):
        """Final state used when the solver exhausted the search space."""
        self.channel.emit(self.SIGNALS.SOLVER_FINISHING)
        self._finish()
        if self.termination is None:
            self.termination = self.TERMINATION.UNKNOWN
        self.meta.termination = self.termination
        self.meta.final_incumbent = self.incumbent
        self.meta.final_bound = self.bound
        self.meta.final_gap = self.gap
        self.meta.total_cpu = self.cpu.total
        self.meta.total_iters = self.iters.total
        self.channel.emit(self.SIGNALS.SOLVER_FINISHED)
        self.report()
        return self.solutions.best()

    @FSM.State
    def solving(self, instance=None, *limits, **params):
        """This state is a shortcut for initiializing the solver with the given instance and
        parameters, and then running the search using the provided limits."""
        self.input(self.ACTION.INIT, instance, *limits, **params)
        if self.state != self.STATE.INITIALIZED:
            self.log.error("unexpected solver state after {!r}: {!r}",
                           self.ACTION.INIT, self.state)
            return None
        return self.input(self.ACTION.RUN)

    # --------------------------------------------------------------------------
    # Solver methods that can/should be redefined in subclasses
    def _reset(self):
        """Set clean-slate state on the solver.  Should be used by subclasses to reset their own
        attributes."""
        pass

    def _init(self):
        """Initialize state on the solver.  Should be used by subclasses to initialize their own
        attributes."""
        pass

    def _bootstrap(self):
        """Implement in subclasses where bootstrapping behavior is required."""
        pass

    def _iterate(self):
        """Implementation required in solver subclasses."""
        raise NotImplementedError()

    def _finish(self):
        """Called when the solver is finishing a search."""
        pass

    def _extract_solution_and_meta(self, sol_container):
        """This method is used by the solver's solution list object to extract solution data and
        metadata from a solution container.  A solution container may hold more information than
        necessary to define a solution, so this method is useful to store only relevant solution
        data in the solution list.  The default implementation simply returns the container,
        unchanged, as the solution data, and an empty metadata dictionary."""
        return sol_container, {}

    # --------------------------------------------------------------------------
    # Command-line interface, interactive session, and reporting
    def cli(self, argv=None):
        """Run the solver's command line interface tool."""
        from .cli import solver_cli
        return solver_cli(self, argv=argv)

    def repl(self):
        """Launch an interactive session (read-eval-print loop) with this solver."""
        from .repl import SolverInteractiveSession
        cls = type(self)
        session = SolverInteractiveSession(self)
        return session.cmdloop(intro="{} version {} - interactive session"
                               .format(cls.__name__, cls.version))

    def report(self, ostream=None):
        """Print a report of the search to 'ostream'. If no output stream is given, the solver's
        info log stream is used."""
        if ostream is None:
            ostream = self.log.info
        ostream.write(repr(self) + "\n")


# attach a transition function to each state, as specified in the TRANSITIONS mapping
for state_name in STATE.itervalues():
    state = getattr(Solver, state_name)
    state.transition_fnc = FSM.State.transition_fnc_from_mapping(TRANSITIONS[state_name])

# create shortcut methods for each action in ACTIONS (e.g. pause, run, init, etc.)
for action in ACTION.itervalues():
    setattr(Solver, action, Solver.input_shortcut(action, name=action))
